Add opt-in upstream API fallback with two independent toggles - #20
Merged
Pijukatel merged 10 commits intoAug 25, 2026
Merged
Conversation
When a call misses locally, the runtime can replay it against the real Apify platform instead of failing. Two independent toggles gate it: one for paths this runtime does not serve at all, one for records it has not seen. Both default off and reset on restart. Readable and writable on the runtime-internal API and from the console's new settings page. Relays only a successful upstream reply, and only ever forwards the token the caller themselves presented; any upstream failure returns the original local error unchanged. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
Relay each Set-Cookie the platform set as its own header line, since the HTTP client only guarantees separate entries for that one name and a cookie value may itself contain a comma. Other repeated header names are relayed comma-joined; the requirements now state that contract rather than promising byte-level preservation of repeated lines. Drop the duplicate upstream-base-URL helper in favour of the existing one, which now trims trailing slashes, and stop the fallback service reaching into the api layer for a two-line buffer coercion. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
An upstream that sent its status line and headers and then died left the relay throwing after the local error was already decided: one seam answered 500 instead of the original error, the other let the rejection reach Express's final handler, which rendered a stack trace as HTML. The relay now absorbs any failure and reports that it did not relay, and both seams answer through one helper. Also match the path exclusion to how Express routes, so a differently-cased or double-slashed spelling of the runtime's own namespace can no longer forward a token upstream; stop the new tests reaching the real platform during identity warm-up; make the relay timeout injectable so the hang test no longer costs 30 seconds; and reject cross-site form submissions to the console's writes. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
The mid-relay error path stripped every upstream header name from the response, including ones it had never appended and ones whose removal has side effects in Node (`date` clears `sendDate`, `connection` sets `_removedConnection`). A fail-closed response therefore dropped `Date`, `Connection` and `Keep-Alive` relative to the both-toggles-off response it is documented to reproduce. The block could not run for the failure it guarded against: the body is read as the first statement of the `try`, before any response mutation, so nothing is ever appended when that read fails. Removed it, and moved the relay's `return true` and success log outside the `try` so a successful relay can no longer be turned back into a fallthrough. Fail-closed tests now compare the full response header set against each request's own both-toggles-off baseline instead of status, body and markers alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
The dev-folder route's doc comment still called it the console's one mutation, which the settings form falsified; the module header and `requirements/console.md` were updated but this sibling sentence was not. `attemptFallback`'s doc comment described its `try`/`catch` as covering everything from the status check onward, which stopped being true when the success log and `return true` moved out of it. The contract it states is unchanged; only its stated basis was wrong, in the comment an editor would consult before adding a statement after `res.send()`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
Two sentences described internals rather than behaviour a caller can observe: the relay bullet explained that the body is re-framed rather than streamed, and the console section explained that both surfaces write through one shared toggle state. Either would have to change if the implementation were rewritten without any observable difference. Both now state only the consequence a caller or console user can see, and one paragraph is rewrapped to the surrounding width after the edit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
The Settings-page section explained that both surfaces write through one underlying toggle state. An implementation keeping two stores in sync would falsify that while behaving identically, so it described internals rather than a contract. The guarantee it was there to support - a flip on either surface is immediately visible on the other and via the API's own GET, with no restart - is unchanged. The previous commit's message described this edit, but it was dropped from the working tree before that commit was made. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
The upstream fallback sections explained how the runtime works rather than what it guarantees: which headers are hop-by-hop, why cookie values cannot be comma-joined, what the spec table matches, how a partial body merges into stored state, and which log level each outcome writes at. None of that is something a caller can observe or rely on. What a caller does rely on stays: the toggles and their defaults, the endpoint contracts, which local outcome each toggle covers, all methods being eligible and what that costs, the fail-closed guarantee, that only the caller's own token is forwarded, and how a relayed response is marked. The retry wording is restated as the guarantee behind it - an eligible request reaches the platform at most once, so a relayed write is never duplicated. The console's cross-site rejection now states the limit of the guarantee as well as the guarantee: a submission that does not identify itself as cross-site is not rejected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
The builds, runs and logs views rendered whatever order the registry happened to return, which it makes no promise about. They now show the most recently started record first, and the logs view interleaves builds and runs by start time instead of listing all builds and then all runs. Records sharing a start time are ordered by id so the sequence is stable across renders. The API's own ordering is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
The comment justified keeping this out of the API's sort helper by saying that helper works on already-paginated DTOs. It does not - at all seven of its call sites it sorts raw records, before pagination and before any DTO conversion. What actually separates the two is the sort direction and the fact that the console and the API are sibling consumers of the service layer, neither importing the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
This was referenced Aug 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
When a call fails locally, the runtime can re-attempt it against the real Apify platform. Two independent toggles gate this, both off by default and both reset on restart:
fallbackUnimplementedEnabled— the runtime does not serve this path at all (local404or501).fallbackNotFoundEnabled— the runtime serves the route, but has no such record (record-not-found).Neither implies the other; all four combinations are valid. Every other error type is never relayed.
Readable and writable at
GET/POST /actor-runtime/api-fallback, and from a new console/settingspage. Every console page's navigation shows both toggles' current values, so neither can be on without being visible. All HTTP methods are eligible, writes included — an eligible write that the platform accepts is a real, billed operation on the caller's own account.A successful relay returns the platform's status and body unchanged, marked with
x-actor-runtime-fallbackandx-actor-runtime-fallback-trigger. Anything else — non-2xx, timeout, unreachable platform — reproduces exactly the response the caller would have gotten with both toggles off. Only the token the caller themselves presented is ever forwarded.Also in this PR, at the reviewer's request during the same work: the console's builds, runs and logs listings now show the most recently started item first.
Why
requirements/api.mdcarried this as a "Not implemented" stub. Without it, a call touching anything the runtime does not have — an Actor that only exists on the platform, a real run's dataset id, an endpoint family this runtime never built — hard-fails, and the only recourse is to unsetAPIFY_CLIENT_BASE_URL, re-run against the real platform, and set it back.Notes
README.mdstill says the runtime needs no outbound network access after the first build/push, and does not mention this feature. Worth deciding separately whether it should.NODE_ENVis unset in the image, so Express's default error handling runs in development mode. No reachable path renders a stack trace after this change, but setting it would be defence in depth.🤖 Generated with Claude Code
https://claude.ai/code/session_01G1KN9bXcgAUCyzevGb4viG
Generated by Claude Code